今天繼續依照先前設計的 API 規格來實作,身分驗證完成之後要來時做活動的管理,一個活動通常會有活動本身資訊及座位資訊,這兩種資料都可以根據我們先前設計的 Schema 存入 DB,會需要一套基本的 CRUD API ,但在售票時並不會從 DB讀取相關的資料而是由 Redis 做存取,因此我們需要再設計一下 Redis 要怎麼儲存這些資料,要考慮到後續售票時的存取效率以及資料一致性。
先建立對資料庫內活動資訊的 CRUD API
建立所需的ViewModel 用於 API 交互
namespace iThome2024.SalesService.ViewModel;
public class EventCreateViewModel
{
public required string Name { get; set; }
public DateTime EventDate { get; set; }
public DateTime StartSalesDate { get; set; }
public DateTime EndSalesDate { get; set; }
public string? Description { get; set; }
public string? Remark { get; set; }
public List<SeatViewModel>? Seats { get; set; }
}
namespace iThome2024.SalesService.ViewModel;
public class SeatViewModel
{
public int EventId { get; set; }
public string? Area { get; set; }
public required string Name { get; set; }
public int Status { get; set; }
}
根據我們先前設計為了降低交互次數,活動資料跟座位資料我們設計在同一個 API,先建立完活動後再把位子寫入。
app.MapPost("/api/event", async (EventCreateViewModel model, [FromServices] TicketSalesContext context) =>
{
// Map EventCreateViewModel to Event
var newEvent = new Event
{
Name = model.Name,
EventDate = model.EventDate,
StartSalesDate = model.StartSalesDate,
EndSalesDate = model.EndSalesDate,
Description = model.Description,
Remark = model.Remark,
};
// 新增 Event
await context.Event.AddAsync(newEvent);
// 新增相關的 Seat
if (model.Seats != null && model.Seats.Any())
{
foreach (var seat in model.Seats)
{
var newSeat = new Seat
{
Name = seat.Name,
Area = seat.Area,
Status = seat.Status,
Event = newEvent
};
await context.Seat.AddAsync(newSeat);
}
}
await context.SaveChangesAsync();
return Results.Created($"/api/event/{newEvent.Id}", newEvent);
});
與新增大致相同,活動的位置這裡我們採用全刪全加的方式,規定傳入的位子就是這個活動最終的座位狀態
app.MapPut("/api/event/{id}", async (int id, EventCreateViewModel model, [FromServices] TicketSalesContext context) =>
{
var entity = await context.Event.FindAsync(id);
if (entity == null)
{
return Results.NotFound();
}
// 更新 Event 的屬性
entity.Name = model.Name;
entity.EventDate = model.EventDate;
entity.StartSalesDate = model.StartSalesDate;
entity.EndSalesDate = model.EndSalesDate;
entity.Description = model.Description;
entity.Remark = model.Remark;
// 更新 Seats
if (model.Seats != null)
{
// 刪除現有的 Seats
context.Seat.RemoveRange(entity.Seats);
// 新增傳入的 Seats
foreach (var seat in model.Seats)
{
seat.EventId = entity.Id; // 設定 Seat 的 EventId
var newSeat = new Seat
{
Name = seat.Name,
Area = seat.Area,
Status = seat.Status,
EventId = entity.Id
};
await context.Seat.AddAsync(newSeat);
}
}
await context.SaveChangesAsync();
return Results.Ok(entity);
});
取得活動分成兩類,一個是活動清單,一個是活動詳細資訊,只有活動詳細資訊會帶出座位資料
app.MapGet("/api/event", async ([FromServices] TicketSalesContext context) =>
{
return await context.Event.ToListAsync();
});
app.MapGet("/api/event/{id}", async (int id, [FromServices] TicketSalesContext context) =>
{
return await context.Event.Include(e => e.Seats).FirstOrDefaultAsync(e => e.Id == id);
});
app.MapDelete("/api/event/{id}", async (int id, [FromServices] TicketSalesContext context) =>
{
var entity = await context.Event.FindAsync(id);
if (entity == null)
{
return Results.NotFound();
}
// 刪除相關的 Seats
context.Seat.RemoveRange(entity.Seats);
// 刪除 Event
context.Event.Remove(entity);
await context.SaveChangesAsync();
return Results.NoContent();
});
正常來說所有的 API 都要做一些資料檢核,例如活動時間跟開始售票時間是否合理,活動是否已經開始就無法更改部分資訊等等,這裡為了方便先不做這部分的檢核
除了寫入 DB 同時也要寫入 Redis 讓售票時可以快速的取得資料,Redis 是一個快取服務,最佳的情況是沒有過多的異動,因此現階段 Redis 不儲存座位的狀態,我們將活動資訊存在 Hash 座位資訊則直接轉成 Json 存在同一張 Hash 內,轉乘 Json 後要再對資料做二次操作成本會相對高,但若只是要取出有哪些座位可供購買,全部存在同一個 Hash 可以最小的 IO 就拿到所需的資料。
我們寫入 Redis 的不需要另外再開 API 它應該要在變更 DB 資料同時去做異動。
為了確保快取一致性我們會在查詢的時候進行快取的寫入,在更新的時候進行快取的刪除
調整一下查詢的 API,先從 Redis 取值,如果取不到再去 DB 取出並寫入 Redis
app.MapGet("/api/event/{id}", async (int id, [FromServices] TicketSalesContext context, [FromServices] RedisService redisService) =>
{
if (await redisService.KeyExistsAsync($"Event:{id}"))
{
var hash = await redisService.HashGetAllAsync($"Event:{id}");
var redisEntry = new Event
{
Id = id,
Name = hash["Name"],
EventDate = DateTime.Parse(hash["EventDate"]),
StartSalesDate = DateTime.Parse(hash["StartSalesDate"]),
EndSalesDate = DateTime.Parse(hash["EndSalesDate"]),
Description = hash["Description"],
Remark = hash["Remark"],
Seats = JsonSerializer.Deserialize<List<Seat>>(hash["Seats"]),
};
return Results.Ok(redisEntry);
}
else
{
var entry = await context.Event.Include(e => e.Seats).FirstOrDefaultAsync(e => e.Id == id);
if (entry == null)
{
return Results.NotFound();
}
foreach (var property in entry.GetType().GetProperties())
{
if (property.Name != "Seats")
{ await redisService.HashSetAsync($"Event:{entry.Id}", property.Name, property.GetValue(entry)!.ToString()!); }
}
await redisService.HashSetAsync($"Event:{entry.Id}", "Seats", JsonSerializer.Serialize(entry.Seats));
return Results.Ok(entry);
}
});
再調整更新的 API 讓更新動作完成時去刪除 Redis 內的資料
app.MapPut("/api/event/{id}", async (int id, EventCreateViewModel model, [FromServices] TicketSalesContext context, [FromServices] RedisService redisService) =>
{
//...
await context.SaveChangesAsync();
await redisService.KeyDeleteAsync($"Event:{id}");
return Results.Ok(entity);
});
我們可以保留原本直接跟 DB 交互的 API 來比較一下從 Redis 取值跟從 DB 取值和者延遲比較低,我們可以用 K6 簡單的壓測試是看 使用同樣的條件500 vus 打 30秒
DB
Redis
可以看到兩者的 TPS 就差了非常多 Redis 的 TPS 有到 4000 多 而直接跟 DB 交互則只有 170TPS,當然會這麼低有一部分是因為我們的 DB 開得很小。